feat(runtime): ScriptContext.user 由 unknown 收窄为 ScriptUser 联合 (#5521) - #6295
Merged
Conversation
沙箱接缝的 user 字段此前是 `unknown`,类型系统对它一无所知 —— 第四个 dispatch 面手搓一个 user 字面量,编译器不会说一句话,而这正是 #5372 三种形状能存在几个 版本的部分原因:没有任何声明可以违背。 现在 `user?: ScriptUser`,`ScriptUser = ActorUser | HookContext['user']`,是两个 实测真实生产者形状的联合,与 33 行外的姊妹字段 ScriptSession(#5613 / #5991)同构。 刻意不收成单一类型:hook 侧的 buildUser() 快捷方式不带 positions / permissions / systemPermissions,收成 ActorUser 会断言一套 hook 面从未生产过的授权词汇;也不收成 spec 的 EvalUser(issue 选项 1)—— 实测 buildUser() 根本不产 positions,而 EvalUser 要求它。 两个写入方的 `?? …session?.user` 兜底链未逼出联合第三支:两种 session 形状均未声明 也未生产 user 键(#4984 死肢家族),该死肢另行立单,本 PR 不动运行时表达式。 类型钉子放在 src 下的非测试文件 script-user-type-assertions.ts —— runtime 的 tsconfig 排除测试文件,钉在测试里就是 PINS_CHECKED 所说的 phantom check。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wbxm29qPKnLf44AbSxizqW
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 21 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 7, 2026
qq9340100
marked this pull request as ready for review
August 7, 2026 13:40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5521
沙箱接缝
ScriptContext的user字段此前是unknown,类型系统对它一无所知。issue 说得很准:第四个 dispatch 面明天再手搓一个 user 字面量,编译器不会说一句话 —— 而"三个 dispatcher 手搓出三种形状"正是 #5372 的成因,它能存在几个版本,部分原因就是没有任何声明可以违背。本 PR 按 #5991 的模子,把它收窄成命名联合
ScriptUser = ActorUser | HookContext['user']。行为零变化,纯类型面。一、测量(#5991 同一测法,针对
user面 —— 先测后改)基线
origin/main@f6609e6ae。写入方(2 处,均在
packages/runtime/src/sandbox/body-runner.ts)body-runner.ts:315buildSandboxContextengineCtx?.user ?? engineCtx?.session?.userHookContext['user']— ObjectQLbuildUser()(packages/objectql/src/engine.ts:1745)body-runner.ts:340buildActionSandboxContextactionCtx?.user ?? actionCtx?.session?.userActorUser(packages/runtime/src/security/actor-user.ts:76),由 REST/actions(domains/actions.ts:325)与 MCPrun_action(action-execution.ts:1013)经actorUserFromExecutionContext()构造两个形参类型都是
any⇒ 收窄后表达式类型仍是any,两处赋值一律编译通过,无需改写入方。读取方(全仓,非测试)—— 逐点:文件:行号 / 收的类型 / 收窄后是否仍编译
packages/runtime/src/sandbox/quickjs-runner.ts:489installCtx→setObjectJson(vm, ctxObj, 'user', ctx.user)setObjectJson(…, v: unknown)(同文件:1031)全仓非测试读取方就这一处。 派发令转述的分诊预判"
user的读取方比session多"在 TS 类型面上不成立,如实记录:多出来的读取方全部在 VM 内部(body 作者写的ctx.user.id之类),那是交给 QuickJS 的 JS 源码字符串,不经 tsc,收窄影响不到它们。所以user面的读取点拓扑与session面相同,不是更复杂。测试面构造
ScriptContext字面量而带user的只有quickjs-runner.test.ts:275(user: { id: 'u1' }),经HookContext['user']支合法。跨仓
分诊 08-07 06:55Z 已核
ScriptContext在 objectui / cloud 零命中(含阳性对照),本轮按派发令免重跑。收窄面止于packages/runtime—— 并由下面的全仓turbo run typecheck125/125 独立复核。二、必答项:两写入方的
?? …session?.user兜底链是否逼出联合第三支?否 —— 而且这条兜底链的第二支在两个写入方上都是死支(#4984 死肢家族)。 逐条证据:
HookContext['session']的键集是userId/actor/organizationId/accessToken/isSystem/skipTriggers/skipAutomations/positions/preserveAudit(外加roles墓碑),没有user键(packages/spec/src/data/hook.zod.ts:386-536);唯一生产者ObjectQLEngine.buildSession()(packages/objectql/src/engine.ts:1647-1690)逐字段构造,同样不写user。ActionSession的键集是userId/organizationId/positions/roles(弃用别名),没有user键(packages/spec/src/ui/action-params.zod.ts:237-336);唯一生产者buildActionSession()(packages/runtime/src/action-execution.ts:811-823)只写这四个键。⇒ 联合恰好两支,未越出照抄
ScriptSession模子的范围。本 PR 不动这条死肢:它是运行时表达式,本单的授权是"给接缝钉类型",顺手删表达式属于扩范围;已按 Prime Directive #10 另行立单(见文末)。
一处真实的第三种取值(是取值,不是第三个形状)
ScopedRepo.execute()(packages/objectql/src/engine.ts:7400-7407)是executeAction的第二个调用点,传的 ctx 是{ ...params, userId, tenantId, roles }—— 既无user也无session,该路径上ctx.user恒为undefined。undefined已被user?:的可选性(以及HookContext['user']自带的 optional)覆盖,不需要额外联合支。这条已写进ScriptUser的 docblock,免得下个读者以为undefined只是拼写上的宽松。三、与 #5991(
ScriptSession)模子的对齐 / 偏离对齐(照抄):导出命名联合而非单型;成员是真实生产者形状而非发明的契约;
undefined经 spec 侧 optional 自然成为成员;类型放在同一个script-runner.ts、紧邻姊妹字段;docblock 写清"为什么不是单型"。偏离,三处,均有测量支撑:
ScriptSession两支都来自 spec(ActionSession/HookContext['session']);user的 action 侧生产者ActorUser今天只有 runtime 的 TS interface、无 spec 契约 —— 这一点已记在 [runtime]ctx.user的roles别名(值是positions)没有关闭日期 —— #5613 给 ctx.session 装了迁移窗口,同名同值的 ctx.user 面仍是无限期别名(observation) #6011,按派发令不归本单,本 PR ⛔ 不发明 spec 契约。EvalUser(issue 选项 1),这是被实测否掉的,不是口味问题。EvalUser(ADR-0068 D1)要求id: string且positions: string[],而 hook 侧的buildUser()根本不产positions键。所以EvalUser是 hook 侧交付形状的超集,拿它当"最小公分母"会在 hook 面断言一个从不存在的键 —— 与直接收成ActorUser是同一种过度声明,只是套了层 spec 外衣。ActorUser extends EvalUser,所以真正有这套契约的 action 路径一点没丢。选项 3(改用闸门)未采纳:类型是这里更便宜、更精确的通道,且ScriptSession已立先例。ActorUser(仅类型)。ScriptSession两支都能被消费者命名(都是公开 spec 类型),ScriptUser要对等就得让ActorUser也可命名,否则导出了联合却没法指名它的一支。只导出type,构造函数保持内部 —— Action context'sctx.user.nameis hardcoded to the raw user id on the REST dispatch path — three dispatchers hand the sandbox three different user shapes #5372 的要义就是只有一个生产者。四、类型钉子放在哪(#6220 的教训)
packages/runtime/tsconfig.json排除所有.test.ts/.spec.ts,而它的typecheck就是一句tsc --noEmit。钉在 runtime 的测试文件里 = phantom check —— 没有任何 tsc program 编译它,删掉指令每个闸门照样绿,这正是check:type-check-coverage的PINS_CHECKED不变式所说的情形,且PHANTOM_PIN_DEBT已对新条目关闭。所以钉子落在
packages/runtime/src/sandbox/script-user-type-assertions.ts——include覆盖src树、不被测试排除规则命中的普通 src 模块,与既有先例packages/spec/src/ui/app.nav-type-assertions.ts同形。它不被任何 tsup entry 或 barrel 引用(entry: ['src/index.ts']),不进产物。已有的运行时钉
packages/runtime/src/action-ctx-user-shape.test.ts(#5372,逐键逐值断言三条 dispatch 路径相等)按派发令引用而不重复:它钉的是 VALUE,本文件钉的是 TYPE,而 VALUE 钉恰好覆盖不到本单要防的那种事 —— 一个新的第四 dispatch 面,因为 pin 只能检查它点名的生产者。钉子分两族:正向(两个真实生产者形状仍可赋 —— 这是防过度收窄的一半:一旦有人把联合塌成
ActorUser,hook 支的断言立刻红)与反向@ts-expect-error(垃圾形状、错类型id、裸字符串、缺键的 action envelope、裸EvalUser,以及读取侧ctx.user?.positions必须先判别 body 种类)。一条钉子的预设被证伪,如实改掉而不是硬凑
原本想钉"
{ id, positions }这种半截 action envelope 应当编译红"。实测它是绿的,tsc 报TS2578: Unused '@ts-expect-error' directive。原因是两条普通 TS 规则叠加:hook 支是 weak type(键全可选),命中一个键即满足;而联合上的 excess-property 检查只拒绝在所有成员里都不存在的键,positions在ActorUser支上是真键,所以不算 excess。于是把它改成两条诚实的钉子:一条反向钉真正成立的情形(
{ userId, positions }—— 与 hook 支无公共键,被 weak-type 检查拒绝),一条把上面那个能编译的情形作为正向断言显式写下来,并在注释里说明这就是"两个无判别式的生产者形状取联合"的能力上限,姊妹字段ScriptSession因为ActionSession同样全可选而有完全一样的上限。声明买到的不是形状良构性证明,而是"与两个生产者都毫无交集的形状(如inventedShape)从此被拒",这是unknown下静悄悄放行的那一类。五、反向验证(先预测,后执行)
做法:把
ScriptUser与字段一起临时放宽回unknown。两处一起放宽才是真正的"改动前"状态 —— 只改字段而留着别名的话,直接标注: ScriptUser的钉子根本不受影响,那是假反向验证。预测(执行前写死,含一条反模板的):方向是红,但不是均匀的 6 条 TS2578。5 条赋值型反向钉会因"任何形状都能赋给
unknown"变成未使用(TS2578);正向读取钉sharedIdIsReadable没有抑制指令,会真错;而positionsNeedDiscrimination不会红 —— 它压的错误只是从"联合上无此键"换成"读不了无类型值",抑制指令照样用得上。实测:6 条错误,与预测的条数与归属完全一致:
positionsNeedDiscrimination如预测未报错。与预测的唯一出入(如实记录):132 行的错误码是
TS2339('{}'上没有id)而非我预测的TS18046(值为unknown)—— 因为?.先把unknown里的 null/undefined 排掉,剩下{},再去读属性。同一类事实(无类型的值读不出属性),错误码不同。已还原,并与放宽前的备份逐字节
diff确认一致。六、验证
pnpm --filter @objectstack/runtime typechecktsc --noEmit无输出)pnpm --filter @objectstack/runtime testturbo run typecheck --concurrency=2(全仓)pnpm lintpnpm check:type-check-coveragePINS_CHECKED:新钉子确实在 tsc program 内)pnpm check:nul-bytespnpm check:empty-changeset/check:adr-anchors/check:org-identifier七、文件面
packages/runtime/src/sandbox/script-runner.ts—— 新增ScriptUser联合 +user?: ScriptUser(唯一的行为无关类型改动)packages/runtime/src/sandbox/script-user-type-assertions.ts—— 新增,类型钉子packages/runtime/src/sandbox/index.ts/src/index.ts—— 导出ScriptUserpackages/runtime/src/security/index.ts/src/index.ts—— 导出type ActorUser.changeset/light-berries-tickle.md——@objectstack/runtimepatchbody-runner.ts/quickjs-runner.ts按派发令只读,零改动 —— 收窄没有暴露任何写入方类型错误(两个写入方从any赋值)。⛔ 未触碰standalone-stack.ts(#5820 在飞)与action-execution.ts/domains/data.ts/endpoint-policy.ts(#6244 落地中);未碰content/docs/releases/、未碰pnpm-lock.yaml。Generated by Claude Code